Circuit Breaker로 연쇄 장애 줄이기

Circuit Breaker로 연쇄 장애 줄이기

한눈에 보기

Circuit Breaker는 최근 호출의 실패가 임계값을 넘으면 회로를 열어 의존성 호출을 즉시 거절한다. 일정 시간이 지난 뒤 Half-Open 상태에서 제한된 탐색 요청으로 회복을 확인한다. Timeout·재시도·동시성 제한을 대체하는 기능이 아니며, 대상과 연산별로 적절한 경계를 정하고 상태 전이를 관측해야 한다.

목차

느린 의존성이 내 서비스까지 멈추게 한다

상품 페이지가 추천 API를 동기 호출한다고 하자.

app.get("/products/:id", async (req, res) => {
  const product = await productRepository.findById(
    req.params.id,
  );

  const recommendations =
    await recommendationApi.load(req.params.id);

  res.json({
    product,
    recommendations,
  });
});

추천 API의 평소 응답은 100ms지만 장애 중 10초 뒤 timeout이 난다. 사용자 요청이 초당 100개라면 장애가 10초 지속되는 동안 대략 1,000개의 호출이 대기할 수 있다.

동시 대기 호출 ≈ 초당 요청 수 × 평균 대기 시간
              ≈ 100 × 10초
              ≈ 1,000

대기 호출은 Promise만 점유하는 것이 아니다. HTTP 소켓, 연결 풀 슬롯, 요청 본문, 사용자 문맥, 로그 추적 객체가 유지된다. 추천 API와 무관한 요청까지 이벤트 루프 지연과 메모리 압박을 받을 수 있다.

여기에 각 호출이 세 번 재시도하면 의존성에는 더 많은 요청이 간다.

flowchart LR
    A[추천 API 지연] --> B[호출 대기 증가]
    B --> C[연결·메모리 점유]
    C --> D[상품 API 지연]
    D --> E[클라이언트·상위 계층 재시도]
    E --> A

Circuit Breaker는 최근 실패를 기억한다. 실패가 지속되는 동안 원격 호출을 시도하지 않고 빠르게 거절해 자원을 보호한다.

실패를 성공으로 바꾸는 패턴은 아니다

회로가 열리면 의존성 호출은 성공하지 않는다. 대신 오래 기다린 실패를 즉시 알 수 있는 실패나 안전한 fallback으로 바꿔 전체 시스템의 장애 범위를 제한한다.

재시도와 Circuit Breaker의 역할은 다르다

두 패턴은 함께 언급되지만 질문이 다르다.

패턴 질문 동작
Timeout 한 시도를 얼마나 기다릴까 제한 시간 뒤 취소
Retry 다음 시도는 성공할 가능성이 있는가 지연 후 다시 호출
Circuit Breaker 지금 호출을 시도할 가치가 있는가 실패 가능성이 높으면 즉시 거절
Bulkhead 이 의존성이 쓸 수 있는 자원은 얼마인가 동시성·풀 격리
Rate Limit 일정 시간에 얼마나 호출할 수 있는가 초과 요청 제한

Circuit Breaker가 없으면 각 사용자 요청이 자신만의 재시도 정책을 처음부터 수행한다.

의존성 장기 장애
사용자 요청 A → 3번 실패
사용자 요청 B → 다시 3번 실패
사용자 요청 C → 다시 3번 실패

회로가 열리면 이후 요청은 의존성에 도달하지 않는다.

요청 A의 실패가 임계값 도달
회로 Open
요청 B → 즉시 CircuitOpenError
요청 C → 즉시 CircuitOpenError

재시도 로직은 CircuitOpenError를 일시 네트워크 오류처럼 다시 시도하지 않아야 한다. 회로가 스스로 정한 탐색 시점까지 기다린다.

Closed Open Half-Open 상태

Closed

정상 상태다. 호출을 통과시키고 결과를 기록한다. 시간 창 안의 실패율과 최소 호출 수가 임계값을 넘으면 Open으로 이동한다.

Open

호출을 원격으로 보내지 않고 즉시 거절한다. 열린 시각과 다음 탐색 가능 시각을 기록한다.

Half-Open

Open 유지 시간이 지난 뒤 제한된 호출만 통과시킨다. 탐색 호출이 충분히 성공하면 Closed로 돌아가고, 실패하면 즉시 Open으로 돌아간다.

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: 최소 표본 충족 + 실패율 초과
    Open --> HalfOpen: openDuration 경과
    HalfOpen --> Closed: 탐색 성공 임계값 충족
    HalfOpen --> Open: 탐색 호출 실패

상태 전이는 원자적이어야 한다. 동시에 여러 요청이 Open 만료를 발견하면 모두 탐색 호출을 보내는 문제가 생길 수 있다.

단순 연속 실패보다 시간 창을 사용한다

“5번 실패하면 Open”만 사용하면 트래픽 규모를 반영하지 못한다.

서비스 A: 1분에 10,000회 중 5회 실패 → 실패율 0.05%
서비스 B: 1분에 6회 중 5회 실패 → 실패율 83%

같은 5회라도 의미가 다르다. 최근 시간 창의 실패율과 최소 표본 수를 함께 본다.

type CircuitThresholds = Readonly<{
  windowMs: number;
  minimumCalls: number;
  failureRate: number;
  openDurationMs: number;
  halfOpenMaxCalls: number;
  halfOpenSuccesses: number;
}>;
최근 30초
최소 호출 20회 이상
실패율 50% 이상
→ Open

최소 호출 수가 없으면 시작 직후 한 번 실패한 것만으로 회로가 열릴 수 있다.

느린 호출도 실패로 볼 수 있다

응답은 200이지만 8초가 걸리면 상위 요청에는 실패와 비슷하다. slowCallThresholdMs와 느린 호출 비율을 별도로 둘 수 있다.

type CallOutcome =
  | { kind: "success"; durationMs: number }
  | { kind: "failure"; durationMs: number }
  | { kind: "ignored"; durationMs: number };

다만 느린 호출 임계값은 실제 p95·p99와 사용자 deadline을 기준으로 정한다. 평소에도 1초가 걸리는 작업에 500ms를 설정하면 회로가 계속 열린다.

Sliding Window 구현 방식

모든 호출을 배열에 영원히 보관하지 않는다.

가상 구현에서는 작은 배열로 설명할 수 있지만 고트래픽 운영에서는 버킷 기반 집계가 적합하다.

무엇을 실패로 집계할지 정한다

모든 예외가 의존성 장애는 아니다.

결과 회로 실패 집계 이유
연결 실패 포함 대상 접근 불가
timeout 포함 지연으로 사용할 수 없음
HTTP 502·503·504 포함 의존성 일시 장애 가능
HTTP 429 정책에 따라 포함 대상 과부하·quota 신호
HTTP 400 제외 호출자 요청 오류
HTTP 401·403 보통 제외·별도 알림 설정·권한 오류
도메인 재고 없음 제외 정상 업무 결과
호출자 Abort 제외 의존성 상태와 무관할 수 있음
CircuitOpenError 제외 실제 호출하지 않음

분류 함수를 주입한다.

type CircuitResult =
  | "success"
  | "failure"
  | "ignored";

function classifyForCircuit(
  error: unknown,
): CircuitResult {
  if (error instanceof CircuitOpenError) {
    return "ignored";
  }

  if (error instanceof RequestAbortedError) {
    return "ignored";
  }

  if (error instanceof HttpResponseError) {
    if ([502, 503, 504].includes(error.status)) {
      return "failure";
    }

    return "ignored";
  }

  if (
    error instanceof NetworkError ||
    error instanceof TimeoutError
  ) {
    return "failure";
  }

  return "ignored";
}

401이 모든 인스턴스에서 발생하면 자격 증명 오류로 호출이 계속 실패할 수 있다. 회로 집계에서 제외하더라도 별도 fast-fail과 알림이 필요하다.

비즈니스 거절을 장애로 세지 않는다

결제 카드 거절이나 재고 부족을 실패로 집계하면 정상 트래픽 패턴 때문에 회로가 열릴 수 있다.

Half-Open에서는 탐색 요청 수를 제한한다

Open 시간이 끝났다고 모든 요청을 한꺼번에 통과시키면 회복 중인 서비스에 다시 파동이 간다.

Open 동안 대기·유입된 요청 1,000개
30초 뒤 Open 만료
1,000개가 동시에 통과
의존성 다시 과부하

Half-Open 상태에서는 소수만 허용한다.

type HalfOpenState = Readonly<{
  kind: "half-open";
  inFlight: number;
  successes: number;
}>;
function canProbe(
  state: HalfOpenState,
  maxCalls: number,
): boolean {
  return state.inFlight < maxCalls;
}

나머지 요청은 즉시 거절하거나 안전한 fallback으로 보낸다. 큐에 무한 대기시키면 빠른 실패라는 목적이 사라진다.

탐색 성공 기준도 정한다.

탐색 최대 동시 호출 2개
연속 성공 3회 → Closed
한 번 실패 → Open

실패 후 Open 시작 시각을 계속 뒤로 미루지 않도록 상태 전이를 한 곳에서 관리한다.

회로를 어느 범위로 나눌 것인가

회로 하나를 모든 외부 호출에 공유하면 한 endpoint 장애가 다른 정상 기능을 막는다.

payment-api
├─ POST /charges       장애
├─ GET /charges/:id    정상
└─ POST /refunds       정상

반대로 요청마다 새 Circuit Breaker를 만들면 실패 상태가 공유되지 않아 의미가 없다.

일반적인 키 후보는 다음과 같다.

dependency + operation + region
const breakers = new CircuitBreakerRegistry();

const chargeBreaker = breakers.get({
  dependency: "payment-api",
  operation: "create-charge",
  region: "ap-northeast",
});

너무 세분화하면 표본이 부족하고 관리할 회로가 많아진다. 다음 기준으로 묶는다.

DB 전체에 회로를 하나 두면 읽기 replica 장애가 쓰기까지 막을 수 있다. 반대로 쿼리마다 만들면 지나치게 많다. 읽기·쓰기 풀이나 업무 기능 단위가 현실적인 경계일 수 있다.

TypeScript로 상태 머신 구성하기

다음은 원리를 설명하기 위한 단일 프로세스용 예시다. 운영에서는 검증된 라이브러리의 동시성, rolling window, telemetry 기능을 우선 검토한다.

type CircuitState =
  | { kind: "closed" }
  | {
      kind: "open";
      openedAt: number;
      retryAt: number;
    }
  | {
      kind: "half-open";
      inFlight: number;
      successes: number;
    };

type Outcome = Readonly<{
  at: number;
  result: "success" | "failure";
}>;
class CircuitBreaker {
  #state: CircuitState = { kind: "closed" };
  #outcomes: Outcome[] = [];

  constructor(
    private readonly thresholds:
      CircuitThresholds,
    private readonly now:
      () => number = Date.now,
  ) {}

  async execute<T>(
    operation: () => Promise<T>,
    classify:
      (error: unknown) => CircuitResult,
  ): Promise<T> {
    this.#moveOpenToHalfOpenIfDue();
    this.#assertCallAllowed();
    this.#markHalfOpenCallStarted();

    const startedAt = this.now();

    try {
      const value = await operation();
      this.#recordSuccess(
        this.now() - startedAt,
      );
      return value;
    } catch (error) {
      const result = classify(error);

      if (result === "failure") {
        this.#recordFailure(
          this.now() - startedAt,
        );
      } else {
        this.#finishIgnoredCall();
      }

      throw error;
    }
  }

허용 여부를 확인한다.

  #assertCallAllowed(): void {
    if (this.#state.kind === "open") {
      throw new CircuitOpenError({
        retryAt: this.#state.retryAt,
      });
    }

    if (
      this.#state.kind === "half-open" &&
      this.#state.inFlight >=
        this.thresholds.halfOpenMaxCalls
    ) {
      throw new CircuitOpenError({
        reason: "half_open_probe_limit",
      });
    }
  }

Open 유지 시간이 지나면 Half-Open으로 이동한다.

  #moveOpenToHalfOpenIfDue(): void {
    if (
      this.#state.kind === "open" &&
      this.now() >= this.#state.retryAt
    ) {
      this.#state = {
        kind: "half-open",
        inFlight: 0,
        successes: 0,
      };

      this.#emitTransition("half-open");
    }
  }

Closed에서는 최근 시간 창의 실패율을 계산한다.

  #recordFailure(durationMs: number): void {
    if (this.#state.kind === "half-open") {
      this.#open("half_open_probe_failed");
      return;
    }

    this.#appendOutcome("failure");

    const recent = this.#recentOutcomes();

    if (
      recent.length >=
        this.thresholds.minimumCalls &&
      failureRate(recent) >=
        this.thresholds.failureRate
    ) {
      this.#open("failure_rate_exceeded");
    }
  }

  #recordSuccess(durationMs: number): void {
    if (this.#state.kind === "half-open") {
      const successes =
        this.#state.successes + 1;
      const inFlight =
        Math.max(0, this.#state.inFlight - 1);

      if (
        successes >=
        this.thresholds.halfOpenSuccesses
      ) {
        this.#state = { kind: "closed" };
        this.#outcomes = [];
        this.#emitTransition("closed");
        return;
      }

      this.#state = {
        kind: "half-open",
        successes,
        inFlight,
      };
      return;
    }

    this.#appendOutcome("success");
  }
  #open(reason: string): void {
    const openedAt = this.now();

    this.#state = {
      kind: "open",
      openedAt,
      retryAt:
        openedAt +
        this.thresholds.openDurationMs,
    };

    this.#emitTransition("open", reason);
  }

  #appendOutcome(
    result: Outcome["result"],
  ): void {
    this.#outcomes.push({
      at: this.now(),
      result,
    });

    const cutoff =
      this.now() - this.thresholds.windowMs;

    this.#outcomes =
      this.#outcomes.filter(
        (outcome) => outcome.at >= cutoff,
      );
  }
}

예시는 상태 머신의 핵심만 보여준다. 동일 이벤트 루프에서는 동기 상태 변경이 끼어들지 않지만 Worker Thread, 여러 프로세스, 원격 상태 저장소를 사용하면 원자적 compare-and-set이 필요하다. 또한 ignored 호출의 Half-Open inFlight 감소, 이벤트 전송 실패 격리, 시간 창의 효율적인 버킷화 같은 세부 구현을 빠뜨리지 않아야 한다.

직접 구현보다 검증된 라이브러리를 먼저 본다

Circuit Breaker는 happy path보다 상태 경쟁, 통계 창, 취소, telemetry가 어렵다. 예시 코드는 개념 학습용으로 두고 운영에는 사용하는 런타임과 HTTP 클라이언트에 맞는 라이브러리를 검토한다.

Fallback은 안전한 기능 저하일 때만 사용한다

추천 API 장애 때 빈 추천 목록을 반환하는 것은 합리적일 수 있다.

async function loadRecommendations(
  productId: string,
): Promise<Recommendation[]> {
  try {
    return await recommendationBreaker.execute(
      () =>
        recommendationApi.load(productId),
      classifyForCircuit,
    );
  } catch (error) {
    if (
      error instanceof CircuitOpenError ||
      isTemporaryDependencyError(error)
    ) {
      return [];
    }

    throw error;
  }
}

하지만 결제 승인 실패를 성공처럼 반환하면 데이터 무결성이 깨진다.

// 위험한 fallback
catch {
  return {
    approved: true,
    source: "fallback",
  };
}

Fallback 선택지는 다음과 같다.

기능 가능한 fallback 주의점
추천 빈 목록·최근 캐시 오래된 데이터 표시 가능
프로필 이미지 기본 이미지 낮은 위험
환율 표시 마지막 정상값 + 시각 거래 계산에는 사용 금지
결제 승인 즉시 실패·대기 상태 성공으로 가장하면 안 됨
재고 차감 큐에 명령 저장 중복과 순서 보장 필요

캐시 fallback에는 freshness를 표시하고 만료 상한을 둔다.

type CachedRate = Readonly<{
  value: number;
  fetchedAt: number;
}>;

function canUseStaleRate(
  rate: CachedRate,
  now: number,
): boolean {
  return now - rate.fetchedAt <= 60_000;
}

사용자에게 기능 저하를 숨길지 알려줄지도 제품 결정이다. “최신 정보가 아닐 수 있음”을 표시해야 하는 업무도 있다.

Timeout Bulkhead Rate Limit과 함께 둔다

Circuit Breaker만 추가해도 첫 임계값에 도달하기 전 호출은 여전히 오래 기다릴 수 있다.

flowchart LR
    A[Caller] --> B[Rate Limit]
    B --> C[Bulkhead 동시성 제한]
    C --> D[Circuit Breaker]
    D --> E[Retry]
    E --> F[Timeout이 있는 실제 호출]

구체적인 순서는 라이브러리와 정책에 따라 달라질 수 있지만 책임은 분리한다.

Timeout

한 호출이 자원을 점유할 최대 시간을 정하고 Circuit Breaker가 느린 실패를 관찰할 수 있게 한다.

Bulkhead

회로가 열리기 전에도 해당 의존성이 사용할 수 있는 동시 연결 수를 제한한다.

const recommendationSemaphore =
  new Semaphore(20);

await recommendationSemaphore.run(
  () => breaker.execute(call, classify),
);

Retry

Closed 상태의 일시 오류를 소수 재시도한다. CircuitOpenError는 재시도하지 않는다. 여러 실패 시도가 회로 통계에 개별 집계되는지 최종 작업 하나로 집계되는지 정책을 정한다.

Rate Limit

회복 직후 트래픽이 한꺼번에 돌아가지 않도록 평상시 호출률을 제한할 수 있다.

회로는 큐 길이를 제한하지 않는다

회로 앞에 무제한 작업 큐가 있으면 Open 동안 요청이 메모리에 계속 쌓일 수 있다. 큐 상한과 빠른 거절도 함께 필요하다.

분산 인스턴스의 회로 상태

API Pod가 20개면 메모리 Circuit Breaker도 20개다.

Pod A: 실패를 많이 관찰해 Open
Pod B: 아직 호출 수가 적어 Closed
Pod C: 새로 시작해 통계 없음

이 상태가 반드시 잘못된 것은 아니다.

로컬 회로의 장점

공유 회로의 장점과 비용

대신 상태 저장소 자체의 가용성, 네트워크 지연, 원자적 전이, Half-Open 탐색 조정이 필요하다. 공유 상태 저장소가 실패했을 때 fail-open과 fail-closed 중 무엇을 선택할지도 정해야 한다.

대부분은 인스턴스 로컬 회로로 시작하고 서비스 메시나 Gateway가 제공하는 중앙화된 기능을 활용한다. 전역 quota처럼 모든 인스턴스가 함께 지켜야 하는 문제는 Circuit Breaker보다 분산 rate limit의 책임에 가깝다.

운영자 강제 상태는 일반 통계와 분리한다.

type AdministrativeOverride =
  | "none"
  | "force-open"
  | "force-closed";

force-closed는 장애 의존성에 트래픽을 쏟을 위험이 있으므로 만료 시간, 권한, 감사 로그가 필요하다.

테스트와 운영 관측

상태 전이 테스트

시계를 주입하면 sleep 없이 검증할 수 있다.

it("opens after the failure threshold", async () => {
  const clock = new FakeClock();
  const breaker = createTestBreaker(clock);

  await recordCalls(breaker, [
    "failure",
    "failure",
    "success",
    "failure",
  ]);

  expect(breaker.state()).toBe("open");
});
it("allows only limited half-open probes", async () => {
  const clock = new FakeClock();
  const breaker = createOpenBreaker(clock);

  clock.advanceBy(30_000);

  const calls = await Promise.allSettled([
    breaker.execute(pendingCall, classify),
    breaker.execute(pendingCall, classify),
    breaker.execute(pendingCall, classify),
  ]);

  expect(
    calls.filter(isCircuitOpenRejection),
  ).toHaveLength(1);
});

분류 테스트

도메인 거절과 호출자 취소가 회로를 열지 않는지 확인한다.

it.each([
  [new TimeoutError(), "failure"],
  [new HttpResponseError(503), "failure"],
  [new HttpResponseError(400), "ignored"],
  [new RequestAbortedError(), "ignored"],
])(
  "classifies %p as %s",
  (error, expected) => {
    expect(classifyForCircuit(error))
      .toBe(expected);
  },
);

장애 주입

1. 의존성 응답을 5초 지연시킨다.
2. 최소 표본 전까지 timeout이 예상대로 제한되는지 본다.
3. 실패율 임계값 뒤 회로가 Open 되는지 본다.
4. Open 동안 실제 의존성 요청 수가 멈추는지 본다.
5. 유지 시간 뒤 탐색 요청만 통과하는지 본다.
6. 의존성 복구 후 Closed로 돌아오는지 본다.

메트릭

상태는 closed=0, half_open=1, open=2처럼 gauge로 표현할 수 있다. requestId나 원본 URL을 라벨로 넣지 않고 정규화한 dependency와 operation을 사용한다.

로그

매 short-circuit마다 동일한 오류 로그를 남기면 장애 중 로그가 폭증한다. 상태 전이는 반드시 기록하고 개별 거절 로그는 샘플링한다.

{
  "dependency": "recommendation-api",
  "operation": "load-related-products",
  "from": "closed",
  "to": "open",
  "reason": "failure_rate_exceeded",
  "failureRate": 0.65,
  "sampleCount": 40,
  "retryAt": "2025-08-19T10:00:30.000Z",
  "message": "circuit state changed"
}

운영 체크리스트

마무리

의존성이 잠깐 실패할 때는 백오프 재시도가 도움이 된다. 장애가 지속되거나 응답이 계속 느릴 때 모든 새 요청이 같은 실패를 다시 확인하도록 두면 내 서비스의 연결과 메모리까지 소진된다.

Circuit Breaker는 최근 호출을 근거로 실패 가능성이 높은 원격 호출을 잠시 차단하고, Half-Open에서 제한된 탐색 요청으로 회복을 확인한다. 회로의 목적은 의존성을 고치는 것이 아니라 실패가 번지는 범위를 제한하는 것이다.

대상별 시간 창과 최소 표본, 실패 분류, 탐색 동시성, 안전한 fallback을 함께 설계해야 한다. Timeout으로 한 시도를 제한하고, Bulkhead로 자원을 격리하며, Retry는 Closed 상태의 일시 실패에만 사용한다. 다음 글에서는 이 모든 패턴의 출발점인 외부 호출의 시간 예산을 더 자세히 다룬다.

참고 자료

관련 노트